fix(engine)!: reject engine calls made outside a template invocation - #2440
Conversation
|
Reviewed at Verified sound
Two things I'd change before merge. 1. The invariant is coupled to the metering lifecycle, and the stated follow-up breaks it
Make the invariant its own state (an explicit 2.
|
…ndow Address review on tari-project#2440. `has_invocation_in_flight()` read `invocation_meter.is_some()`, which only distinguishes a template function invocation from the `tari_alloc`/`tari_free` around it because `end_metered_invocation()` happens to run before the free. Widening the metering window — to charge the alloc and free the engine drives, say — would silently re-admit the calls this refuses. Track the invocation window as its own state, opened and closed around `func.call` alone. Record the refusal in its own slot rather than in `last_engine_error`, which the normal dispatch path also writes. Sharing it meant a `RuntimeError` raised mid-invocation and swallowed by a template that ignored the null pointer now surfaced out of `free_checked`, misreported as an illegal engine call and rejecting a transaction that previously succeeded — a second consensus change that was not the intent of this branch. Cover the `tari_alloc` half with its own test. It is the more dangerous side: before the guard, an engine call from `tari_alloc` recursed host to WASM and back once per response allocation and aborted the process with a stack overflow, since the response allocation calls `tari_alloc` again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
|
Reviewed Addressed
Two new findings. 1. The recursion is still open, and it is a node crash
The machinery added in env_mut.exit_template_invocation();
let ptr = env_mut.alloc(&mut store, len as u32);
env_mut.enter_template_invocation();
take_refused_engine_call(env_mut)?;
let ptr = ptr?;That also makes the rule uniform and matches what the PR title already claims: no engine call from 2.
|
…loc/tari_free Address review on tari-project#2440. `alloc_checked`/`free_checked` held the `&mut WasmEnv` from `data_and_store_mut()` across the call into the template's `tari_alloc` or `tari_free`. A template that calls `tari_engine` from either one has wasmer hand `tari_engine_entrypoint` a second `&mut` to that same environment, which it writes the refusal through while the outer borrow is still live and later read from — the path `test_engine_call_in_tari_alloc` exercises. Interior mutability used to make this benign, since the host held a distinct `WasmEnv` whose fields were shared behind `Arc`; taking the fields down to plain values made the aliasing real. Clone the exported function out of the environment, drop the borrow, call, then re-borrow to drain the refusal. `WasmEnv::alloc` keeps its `&self` form for `handle`, which receives the borrow from its caller and so cannot release it here; that call site is where the response-allocation recursion lives and is being addressed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
The engine enters WASM outside of any invocation twice per call: once to run the template's `tari_alloc` when staging the `CallInfo`, and once to run its `tari_free` on the pointer the template function returned. A template that calls `tari_engine` from either one reached the full engine op set from a context the runtime attributes to no invocation. Both halves of that are wrong. The effects commit — an `EmitLog` issued from `tari_free` lands in the finalized result — and the WASM that produced them is never charged, because `invoke` ends the invocation meter and records its consumption before freeing the return pointer, so the mid-call meter sync in `tari_engine_entrypoint` is a no-op. Refuse the call when no invocation is in flight, a condition the engine already tracks in `WasmEnv::invocation_meter`. The entrypoint can only signal a refusal by returning a null pointer, which a template is free to ignore and return normally, so the refusal is recorded as the last engine error and surfaced by the host once `tari_alloc`/`tari_free` return. This does not close the unbilled compute itself: a `tari_free` that burns cycles without calling the engine is still uncharged, bounded only by the instance's leftover wasmer allowance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
…ndow Address review on tari-project#2440. `has_invocation_in_flight()` read `invocation_meter.is_some()`, which only distinguishes a template function invocation from the `tari_alloc`/`tari_free` around it because `end_metered_invocation()` happens to run before the free. Widening the metering window — to charge the alloc and free the engine drives, say — would silently re-admit the calls this refuses. Track the invocation window as its own state, opened and closed around `func.call` alone. Record the refusal in its own slot rather than in `last_engine_error`, which the normal dispatch path also writes. Sharing it meant a `RuntimeError` raised mid-invocation and swallowed by a template that ignored the null pointer now surfaced out of `free_checked`, misreported as an illegal engine call and rejecting a transaction that previously succeeded — a second consensus change that was not the intent of this branch. Cover the `tari_alloc` half with its own test. It is the more dangerous side: before the guard, an engine call from `tari_alloc` recursed host to WASM and back once per response allocation and aborted the process with a stack overflow, since the response allocation calls `tari_alloc` again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
`WasmProcess` kept its own clone of `WasmEnv` alongside the one wasmer owns, so every piece of shared state had to sit behind an `Arc<Mutex<_>>` for writes made through one copy to be visible from the other. The engine runs one instance at a time on one thread, so the locking bought nothing. `FunctionEnv::as_mut` hands out `&mut T` to whoever holds the store, and `FunctionEnvMut::data_and_store_mut` splits the borrow where the environment and the store are both needed at once — as the host call handlers already do. Hold the `FunctionEnv` handle instead of a clone and take the fields down to plain `Option`/`bool`. `on_panic_handler` now returns the message out of the memory-slice closure and records it afterwards, rather than mutating the environment from inside a closure that borrows it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
…loc/tari_free Address review on tari-project#2440. `alloc_checked`/`free_checked` held the `&mut WasmEnv` from `data_and_store_mut()` across the call into the template's `tari_alloc` or `tari_free`. A template that calls `tari_engine` from either one has wasmer hand `tari_engine_entrypoint` a second `&mut` to that same environment, which it writes the refusal through while the outer borrow is still live and later read from — the path `test_engine_call_in_tari_alloc` exercises. Interior mutability used to make this benign, since the host held a distinct `WasmEnv` whose fields were shared behind `Arc`; taking the fields down to plain values made the aliasing real. Clone the exported function out of the environment, drop the borrow, call, then re-borrow to drain the refusal. `WasmEnv::alloc` keeps its `&self` form for `handle`, which receives the borrow from its caller and so cannot release it here; that call site is where the response-allocation recursion lives and is being addressed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
7d47c2c to
b6e23d2
Compare
…invoke Reaching the environment through the `FunctionEnv` handle spelled every access `self.fn_env.as_ref(store)`, which rustfmt breaks across four lines. That pushed `invoke` past the `too_many_lines` threshold. Name the two accessors `env`/`env_mut` so a read fits on one line, and lift the metering allowance into its own function returning a `MeteringAllowance`. The block computing it was self-contained already: what remains in `invoke` is the call, the accounting around it and the result handling. `env_mut` was the borrow-splitting helper; it is now `env_and_store`, which is what it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
|
Reviewed
Rebase — compared each rewritten commit's patch against its original: identical modulo hunk offsets, except the one real conflict in
Nit: Two things before merge, neither about the code:
The remaining |
Closes #2442, which has the mechanism and the impact. ## Why option 3 and not option 2 #2442 leaned to option 2 — take response allocation away from the template entirely. I designed it out, and it does not survive contact: - **The region cannot be sized.** `ComponentManager::get_state()` returns the *whole* component state, and the generated dispatcher issues it on every method call, so a host-owned region is sized by the largest state any template holds, on the hottest path. A template's entire linear memory is 2 MiB (`max_memory_pages = 32`), and `max_substate_size` is 1 MiB, so preserving today's semantics means handing half the memory budget to a buffer. - **The escape hatch does not work.** A per-template `#[template(io_region_kib = N)]` was the answer to that, with a write-side check to stop a template writing state larger than the region that has to read it back — otherwise a component is written successfully and then permanently uncallable. But `GetState` can read *another* template's component, so the writer's region is not the reader's. The check has to be against a global ceiling, which puts a single fixed cap straight back and leaves the knob buying nothing. - **Template-supplied buffers need host-side state.** The alternative — template passes the destination, host writes into it — requires the template to size the buffer before it knows the response length, and engine ops cannot be re-run to find out. The host has to hold the encoded response between two calls. Multipart is the same stash with more round trips. So option 2 as scoped would have shipped a knob that does not knob, a new consensus check, and a hard ABI break requiring every template to be republished — to remove a class of bugs that option 3 closes for three lines. **The performance argument does not carry it either.** I measured the response allocation across the engine suite: 6,341 of them, median 7 bytes, p99 692, max 1,964 — nothing near any plausible region size. Each costs ~126 metered points, which is **~1% of a transaction's WASM points** (0.4-1.2% across sampled transactions), or ~2% counting the matching free. Real, but not what an ABI break is for. What option 3 does not do is retire the class: the engine still calls template-supplied `tari_alloc` and `tari_free`, and the invariant lives in three hand-written brackets rather than at the boundary. Worth its own issue if it is picked up later — the sizing data above says the right shape is a small region (4 KiB covers everything observed) with a fallback, not a region sized for the worst case. ## Implementation `handle` writes each engine call's response through the template's `tari_alloc`, so servicing a call runs template code while an invocation is in flight. That allocation moves into `alloc_response`, which shuts the invocation window around it: ```rust let was_open = env.data_mut().suspend_template_invocation(); let result = alloc_fn.call(&mut *env, len); env.data_mut().restore_template_invocation(was_open); take_refused_engine_call(env.data_mut())?; ``` The window is restored rather than reopened, so the bracket is correct wherever it is called from rather than only behind the entrypoint guard. Same mechanism #2440 used for the two entry points that already ran outside an invocation, so all three places the engine drives template code are now closed to engine calls. `handle` takes the `FunctionEnvMut` rather than a pre-split `(&mut WasmEnv, StoreMut)`. It needs to mutate the environment either side of a call that re-enters WASM, and the environment must not stay borrowed across that call — the refusal is recorded through the engine's own `&mut` to it. The dispatch sites in `tari_engine_entrypoint` pass `&mut env`, and the guard/meter split is scoped to a block ahead of them. `WasmEnv::alloc` is gone; `alloc_response` is the only caller left and it needs the borrow split. ## Refusals are drained on both paths A refusal now fails the call before the `Ok`/`Err` match rather than only on the trap path. `tari_engine_entrypoint` can answer a refused call only with a null pointer, and a template is free to ignore that and return normally — which is exactly what the new test template does. Checking only the trap path let the refusal be dropped and the transaction commit. This also picks up the case flagged in review on #2440: an engine error raised mid-invocation and swallowed by a template no longer disappears. ## Test `test_engine_call_in_response_alloc` adds a third `buggy` variant whose `tari_alloc` calls the engine once an invocation is under way — a static flag skips the `CallInfo` allocation, which #2440 already refuses before the invocation begins, so the response allocation is the one that runs. It ignores the null it gets back and returns normally. Verified load-bearing: with the bracket removed the test aborts the process with `fatal runtime error: stack overflow` (SIGABRT). With it, the transaction rejects with `EngineCallOutsideInvocation`. `cargo test -p tari_engine` passes in full (36 suites); `cargo lints clippy` clean. ## Breaking Consensus-affecting: a transaction whose template calls the engine from `tari_alloc` during an invocation previously crashed the node and now rejects. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Description
The engine enters WASM outside of any invocation twice per call: once to run the template's
tari_allocwhen staging theCallInfo, and once to run itstari_freeon the pointer the template function returned. A template that callstari_enginefrom either one reaches the full engine op set from a context the runtime attributes to no invocation.From
tari_free— the effects commit and the compute is free. AnEmitLogissued there lands in the finalized result, and the WASM that produced it is never charged:invokecallsend_metered_invocation()andrecord_wasm_execution(...)beforeenv.free(...), so with the meter torn down the mid-call sync intari_engine_entrypointreturnsNone. Measured: 200k volatile loop iterations added totari_freeleftwasm_execution_pointsat 116 — byte-identical to the empty version.From
tari_alloc— the node dies. The host allocates each engine-call response throughtari_alloc, so atari_allocthat calls the engine recurses host→WASM→host once per response. Verified against this branch with the guard disabled:thread has overflowed its stack / fatal runtime error: stack overflow, aborting(SIGABRT).max_call_depthdoes not apply — it is all one call frame — and the recursion is driven by the host, so the wasmer meter does not bound it either.There is no recursion in the
tari_freecase, incidentally: the engine never frees the response to an engine call (the template owns it), sofree -> engine call -> freedoes not cycle. That call simply succeeds where it should not.Fix
Refuse the call in
tari_engine_entrypointwhen no template function invocation is in flight. New error:RuntimeError::EngineCallOutsideInvocation { op }.The window is tracked as its own state (
WasmEnv::in_template_invocation), opened and closed aroundfunc.callalone, deliberately not derived frominvocation_meter. The two coincide today only becauseend_metered_invocation()runs before the free; widening the metering window later — to charge the alloc and free the engine drives, say — must not silently re-admit these calls.The entrypoint can only signal a refusal by returning a null pointer, and a template is free to ignore that and return normally, so the refusal is recorded in its own slot (
WasmEnv::refused_engine_call) and surfaced host-side byalloc_checked/free_checked, which wrap the two entries into WASM that happen outside an invocation. The slot is kept separate fromlast_engine_error, which the normal dispatch path writes: sharing it would make a mid-invocation error swallowed by a template surface as though the free had made an illegal engine call, rejecting a transaction that previously succeeded.handle'senv_mut.allocis untouched: that runs mid-invocation, where the meter is live and allocation is legitimate.Tests
Two cases added to the
buggytemplate suite —test_engine_call_in_tari_freeandtest_engine_call_in_tari_alloc. Each builds a template that callstari_enginefrom that hook, ignores the null it gets back, and returns normally; both transactions are rejected withEngineCallOutsideInvocation.The variants supply their own
tari_alloc/tari_freeand deliberately do not linktari_template_abi, whosetari_freewould collide on the#[no_mangle]symbol. They carry a hardcoded_ABI_TEMPLATE_DEFdeclaring one function so the template is callable and the engine actually reaches both hooks.cargo test -p tari_enginepasses in full (36 suites).Also in this branch
The
buggytemplate's extern block declareddebug, which matches no host import — the engine importstari_debug. Renamed, and given#[link(wasm_import_module = "env")]so the import resolves under the current wasm32 target rules. Unused by the existing variants, so inert for them; needed by the new ones.Not covered
The unbilled compute itself. A
tari_freethat burns cycles without calling the engine is still uncharged, bounded only by the instance's leftover wasmer allowance — and each instruction gets a fresh instance. Closing that means moving the accounting after the free, or opening a second metered window around it. Larger change, left out of this PR — and note the guard above is written so that change cannot reopen this hole.Host recursion from
tari_allocinside an invocation — tracked as #2442. Pre-existing and untouched:handleallocates every engine-call response through the template'stari_alloc, so the same host→WASM→host recursion is reachable from atari_alloccalled during a legitimate invocation, where engine calls are and must remain permitted. Verified to still abort the process with this branch applied.Breaking
Consensus-affecting: a transaction whose template calls the engine from
tari_alloc/tari_freepreviously committed (or crashed the node) and now rejects.